<?php

    #-------------------------------------------------------------#
	# This file is part of the Tekuna MVC Microframework for PHP. #
	# (c) 2009 Niels Jäckel <niels.jaeckel@tekuna.org>            #
	# Please visit http://tekuna.org/ for more information.       #
	#                                                             #
	# This software is licensed under the MIT License. Please     #
	# view the LICENSE file for the full text of this license.    #
    #-------------------------------------------------------------#


	Tekuna :: load('org.tekuna.core.persistence.TextDBException');


	/**
	 * Simple database based on text files. One TextDB instance represents one
	 * data table with several columns. All TextDB tables have an index column
	 * that contains an automatically increased integer value.
	 *
	 * For each table (each TextDB instance) is created one physical text file.
	 */

	class TextDB {

		protected
			$sTable = '',
			$sSavePath = '',
			$arrColumns = '',
			$arrData = array(),
			$bAutoCommit = TRUE,
			$iNextID = 1;

		const
			FIELD_SEPARATOR = "\t|||\t",
			NL_REPLACEMENT = "||||\\n||||",
			CR_REPLACEMENT = "||||\\r||||";



		/**
		 * TextDB's constructor
		 *
		 * @param string $sTable table name
		 * @param array $arrColumns optional; preset the table's columns
		 */

		public function __construct($sTable, $arrColumns = NULL) {

			// store given table
			$this -> setTable($sTable);

			// set default save path
			$this -> setSavePath(APPLICATION_ROOT .'textdb'. DIRECTORY_SEPARATOR);

			// set columns if given
			if ($arrColumns !== NULL) {

				$this -> setColumns($arrColumns);
			}
		}


		/**
		 * Sets the name of the used table and so the filename of the file.
		 *
		 * @param string $sTable name of the table.
		 */

		function setTable($sTable) {

			$this -> sTable = $sTable;
		}


		/**
		 * Returns the name of the table that is used for saving.
		 *
		 * @return string table name
		 */

		function getTable() {

			return $this -> sTable;
		}


		/**
		 * Sets the save path for the file.
		 *
		 * @param string $sPath Path with / or \ at the end
		 */

		function setSavePath($sPath) {

			$this -> sSavePath = $sPath;
		}


		/**
		 * Returns the current used save path
		 *
		 * @return string path
		 */

		function getSavePath() {

			return $this -> sSavePath;
		}


		/**
		 * Set the column names of the table.
		 *
		 * @param array $arrColumns array of column names
		 */

		function setColumns(array $arrColumns) {

			// check, that any columns are given
			if (count($arrColumns) == 0) {

				throw new InvalidArgumentException("The columns array must not be null.");
			}

			// set columns only if array and entries are unique
			if (count($arrColumns) == count(array_unique($arrColumns))) {

				$this -> arrColumns = array_merge(array('id'), $arrColumns);
			}
			else {

				$this -> arrColumns = array('id');
				throw new InvalidArgumentException("The column names '". implode(', ', $arrColumns) ."' are not unique.");
			}
		}


		/**
		 * Returns the array of columns including leading id-column.
		 *
		 * @return array columns
		 */

		function getColumns($bShowIDColumn = FALSE) {

			if ($bShowIDColumn) {

				return $this -> arrColumns;
			}
			else {

				return array_slice($this -> arrColumns, 1);
			}
		}


		/**
		 * Enable or disable implicit commit.
		 *
		 * @param boolean $bCommit
		 */

		function setAutoCommit($bCommit) {

			$this -> bAutoCommit = $bCommit;
		}


		/**
		 * Returns the completely path and filename for storage.
		 *
		 * @return string filename
		 */

		public function getFileName() {

			return $this -> sSavePath . $this -> sTable .'.textdb';
		}


		/**
		 * Load the table data from file. If the data is still loaded,
		 * nothing happens. If the data file does not exist or is not
		 * readable, a TextDBException is thrown.
		 */

		protected function loadTable() {

			// check if the data are still loaded
			if ($this -> arrData !== array()) {

				return;
			}

			// return if database file does not exist
			if (! file_exists($this -> getFileName())) {

				$this -> arrData = array();
				return;
			}

			// check the file name for existence
			if (file_exists($this -> getFileName()) && ! is_readable($this -> getFileName())) {

				$this -> iNextID = 0;
				throw new TextDBException("The database file '". $this -> getFileName() ."' exists, but is not readable.");
			}

			// load the file's contents
			$arrLines = file($this -> getFileName());

			// process all lines
			foreach ($arrLines as $iID => $sLine) {

				// remove trailing linebreak or carriage return
				if (preg_match("~^(.*)[\r\n]*$~", $sLine, $arrRes)) {

					$sLine = $arrRes[1];
				}

				// save next id pointer
				if ($iID == 0) {

					$this -> iNextID = intval($sLine);
					continue;
				}

				// save data
				$arrData = explode(TextDB :: FIELD_SEPARATOR, $sLine);
				$arrDat = array();

				foreach ($arrData as $iID => $sValue) {

					// restore \n and \r
					$sValue = str_replace(TextDB :: NL_REPLACEMENT, "\n", $sValue);
					$sValue = str_replace(TextDB :: CR_REPLACEMENT, "\r", $sValue);
					
					if (isset($this -> arrColumns[$iID])) {

						$arrDat[$this -> arrColumns[$iID]] = $sValue;
					}
					else {

						$arrDat[$iID] = $sValue;
					}
				}

				$this -> arrData[] = $arrDat;
			}
		}


		/**
		 * Returns all table data.
		 * like SQL: SELECT * FROM ...
		 *
		 * @return array data
		 */

		function selectAll() {

			$this -> loadTable();
			return $this -> arrData;
		}


		/**
		 * Returns filtered data.
		 * like SQL: SELECT * FROM ... WHERE $sColumn = $sValue
		 *
		 * @param string $sColumn column name of referenced column
		 * @param string $sValue value in referenced column
		 * @return array data
		 */

		function selectWhere($sColumn, $sValue) {

			$this -> loadTable();
			$arrResult = array();

			foreach ($this -> arrData as $arrData) {

				if (isset($arrData[$sColumn]) && $arrData[$sColumn] == $sValue) {

					$arrResult[] = $arrData;
				}
			}

			return $arrResult;
		}


		/**
		 * Returns count of lines in table.
		 * like SQL: SELECT count(*) FROM ...
		 *
		 * @return integer count
		 */

		function selectCount() {

			$this -> loadTable();
			return count($this -> arrData);
		}


		/**
		 * Saves current data to file.
		 */

		function commit() {

			// build DB content
			$sContent = $this -> iNextID ."\n";
			foreach ($this -> arrData as $arrDat) {
			
				// replace \n and \r in the data
				$sLine = implode(TextDB :: FIELD_SEPARATOR, $arrDat);
				$sLine = str_replace("\n", TextDB :: NL_REPLACEMENT, $sLine);
				$sLine = str_replace("\r", TextDB :: CR_REPLACEMENT, $sLine);

				$sContent .= $sLine ."\n";
			}

			try {

				// write to file
				file_put_contents($this -> getFileName(), $sContent);
			}
			catch (Exception $e) {

				throw new TextDBException("Error while storing the database.", $e);
			}
		}


		/**
		 * Insert some new data in table
		 * like SQL: INSERT INTO ... (...) VALUES (...)
		 *
		 * If the given columns do not match the columns of the table
		 * a TextDBException is thrown.
		 *
		 * @param array data
		 *
		 * @return integer inserted ID
		 */

		function insert($arrInsert) {

			// check insert data
			$arrRawInsertKeys = array_keys($arrInsert);
			$arrInsertKeys = $arrRawInsertKeys;
			sort($arrInsertKeys);
			$arrTableColumns = $this -> getColumns();
			sort($arrTableColumns);

			// check columns
			if (implode(', ', $arrInsertKeys) != implode(', ', $arrTableColumns)) {

				throw new TextDBException('The given Structure {'. implode(', ', $arrRawInsertKeys) .'} does not match the table\'s columns {'. implode(', ', $this -> getColumns()) .'}');
			}

			$this -> loadTable();
			$iInsertID = $this -> iNextID;

			// append to content array
			$arrTmp = array('id' => $iInsertID);
			foreach ($this -> getColumns() as $sColumn) {

				$arrTmp[$sColumn] = $arrInsert[$sColumn];
			}
			$this -> arrData[] = $arrTmp;


			// increase new id counter
			$this -> iNextID++;

			if ($this -> bAutoCommit) {

				$this -> commit();
			}

			return $iInsertID;
		}


		/**
		 * Updates a row in the table.
		 * like SQL: UPDATE ... SET $sSetColumn = $sSetValue WHERE $sWhereColumn = $sWhereValue
		 *
		 * @param string $sWhereColumn referenced column
		 * @param string $sWhereValue referenced value in column
		 * @param string $sSetColumn new value column
		 * @param string $sSetValue new value
		 */

		function updateWhere($sWhereColumn, $sWhereValue, $sSetColumn, $sSetValue) {

			$this -> loadTable();
			$arrDataNew = array();

			foreach ($this -> arrData as $arrData) {

				if (isset($arrData[$sWhereColumn]) && isset($arrData[$sSetColumn]) && $arrData[$sWhereColumn] == $sWhereValue) {

					// update entry
					$arrData[$sSetColumn] = $sSetValue;
				}

				$arrDataNew[] = $arrData;
			}

			$this -> arrData = $arrDataNew;

			if ($this -> bAutoCommit) {

				$this -> commit();
			}
		}


		/**
		 * Deletes all data in table
		 * like SQL: DELETE FROM ...
		 */

		function deleteAll() {

			$this -> loadTable();
			$this -> arrData = array();

			if ($this -> bAutoCommit) {

				$this -> commit();
			}
		}


		/**
		 * Deletes a row in the table
		 * like SQL: DELETE FROM ... WHERE $sColumn = $sValue
		 *
		 * @param string $sColumn referenced Column
		 * @param string $sValue referenced Value
		 */

		function deleteWhere($sColumn, $sValue) {

			$this -> loadTable();
			$arrStay = array();

			foreach ($this -> arrData as $arrData) {

				if (! isset($arrData[$sColumn]) || $arrData[$sColumn] != $sValue) {

					$arrStay[] = $arrData;
				}
			}

			$this -> arrData = $arrStay;

			if ($this -> bAutoCommit) {

				$this -> commit();
			}
		}


		/**
		 * deletes the complete table.
		 * like SQL: DROP TABLE ...
		 *
		 * Physically it is not deleted but only renamed: a .dropped postfix
		 * is added to the file name. To recover a such deleted table, just
		 * rename it back by removing this postfix.
		 */

		function dropTable() {

			$this -> arrData = array();
			$this -> iNextID = 1;

			$sOldName = $this -> getFileName();
			$sNewName = $sOldName . '.dropped';

			// erase dropped-file if exists
			if (file_exists($sNewName)) {

				unlink($sNewName);
			}

			// rename to dropped-file
			if (file_exists($sOldName)) {

				if (rename($sOldName, $sNewName)) {

					clearstatcache();
				}
				else {

					throw new TextDBException("Could not perform table drop. Error while renaming the file '$sOldName' to '$sNewName'.");
				}
			}
		}
	}

